index.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. import { useMonaco } from '@monaco-editor/react'
  2. import { useLocalStorage } from '@uidotdev/usehooks'
  3. import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common'
  4. import dayjs from 'dayjs'
  5. import type { editor } from 'monaco-editor'
  6. import { useRouter } from 'next/router'
  7. import { useEffect, useMemo, useRef, useState } from 'react'
  8. import { toast } from 'sonner'
  9. import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui'
  10. import {
  11. EXPLORER_DATEPICKER_HELPERS,
  12. getDefaultHelper,
  13. LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD,
  14. TEMPLATES,
  15. } from '@/components/interfaces/Settings/Logs/Logs.constants'
  16. import { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
  17. import { LogData, LogsWarning, LogTemplate } from '@/components/interfaces/Settings/Logs/Logs.types'
  18. import { UpdateSavedQueryModal } from '@/components/interfaces/Settings/Logs/Logs.UpdateSavedQueryModal'
  19. import {
  20. maybeShowUpgradePromptIfNotEntitled,
  21. useEditorHints,
  22. } from '@/components/interfaces/Settings/Logs/Logs.utils'
  23. import {
  24. buildLogQueryParams,
  25. resolveLogDateRange,
  26. } from '@/components/interfaces/Settings/Logs/logsDateRange'
  27. import LogsQueryPanel from '@/components/interfaces/Settings/Logs/LogsQueryPanel'
  28. import { LogTable } from '@/components/interfaces/Settings/Logs/LogTable'
  29. import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt'
  30. import DefaultLayout from '@/components/layouts/DefaultLayout'
  31. import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout'
  32. import CodeEditor from '@/components/ui/CodeEditor/CodeEditor'
  33. import LoadingOpacity from '@/components/ui/LoadingOpacity'
  34. import ShimmerLine from '@/components/ui/ShimmerLine'
  35. import { useContentQuery } from '@/data/content/content-query'
  36. import {
  37. UpsertContentPayload,
  38. useContentUpsertMutation,
  39. } from '@/data/content/content-upsert-mutation'
  40. import useLogsQuery from '@/hooks/analytics/useLogsQuery'
  41. import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState'
  42. import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
  43. import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
  44. import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
  45. import { useUpgradePrompt } from '@/hooks/misc/useUpgradePrompt'
  46. import { uuidv4 } from '@/lib/helpers'
  47. import { useProfile } from '@/lib/profile'
  48. import { useTrack } from '@/lib/telemetry/track'
  49. import type { LogSqlSnippets, NextPageWithLayout } from '@/types'
  50. const LOCAL_PLACEHOLDER_QUERY =
  51. 'select\n timestamp, event_message, metadata\n from edge_logs limit 5'
  52. const PLATFORM_PLACEHOLDER_QUERY =
  53. 'select\n cast(timestamp as datetime) as timestamp,\n event_message, metadata \nfrom edge_logs \nlimit 5'
  54. export const LogsExplorerPage: NextPageWithLayout = () => {
  55. useEditorHints()
  56. const monaco = useMonaco()
  57. const router = useRouter()
  58. const { profile } = useProfile()
  59. const { ref, q, queryId } = useParams()
  60. const track = useTrack()
  61. const projectRef = ref as string
  62. const { logsShowMetadataIpTemplate } = useIsFeatureEnabled(['logs:show_metadata_ip_template'])
  63. const allTemplates = useMemo(() => {
  64. if (logsShowMetadataIpTemplate) return TEMPLATES
  65. else return TEMPLATES.filter((x) => x.label !== 'Metadata IP')
  66. }, [logsShowMetadataIpTemplate])
  67. const editorRef = useRef<editor.IStandaloneCodeEditor>(null)
  68. const [editorId] = useState<string>(uuidv4())
  69. const { search, setSearch, timestampStart, timestampEnd, setTimeRange } = useLogsUrlState()
  70. const defaultHelper = useMemo(() => getDefaultHelper(EXPLORER_DATEPICKER_HELPERS), [])
  71. const initialDatePickerValue = useMemo<DatePickerValue>(() => {
  72. if (timestampStart && timestampEnd) {
  73. return { from: timestampStart, to: timestampEnd, isHelper: false }
  74. }
  75. if (timestampStart) {
  76. return { from: timestampStart, to: timestampEnd || '', isHelper: false }
  77. }
  78. return {
  79. from: defaultHelper.calcFrom(),
  80. to: defaultHelper.calcTo(),
  81. isHelper: true,
  82. text: defaultHelper.text,
  83. }
  84. }, [timestampStart, timestampEnd, defaultHelper])
  85. const [datePickerValue, setDatePickerValue] = useState<DatePickerValue>(initialDatePickerValue)
  86. const { logsDefaultQuery } = useCustomContent(['logs:default_query'])
  87. const PLACEHOLDER_QUERY = IS_PLATFORM
  88. ? (logsDefaultQuery ?? PLATFORM_PLACEHOLDER_QUERY)
  89. : LOCAL_PLACEHOLDER_QUERY
  90. const [editorValue, setEditorValue] = useState<string>(PLACEHOLDER_QUERY)
  91. const [saveModalOpen, setSaveModalOpen] = useState<boolean>(false)
  92. const [warnings, setWarnings] = useState<LogsWarning[]>([])
  93. const [selectedLog, setSelectedLog] = useState<LogData | null>(null)
  94. const [recentLogs, setRecentLogs] = useLocalStorage<LogSqlSnippets.Content[]>(
  95. `project-content-${projectRef}-recent-log-sql`,
  96. []
  97. )
  98. const [useOtelEndpoint, setUseOtelEndpoint] = useLocalStorage<boolean>(
  99. `logs-explorer-use-otel-endpoint-${projectRef}`,
  100. false
  101. )
  102. const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days')
  103. const entitledToAuditLogDays = getEntitlementNumericValue()
  104. const { data: content } = useContentQuery({
  105. projectRef: ref,
  106. type: 'log_sql',
  107. })
  108. const query = content?.content.find((x) => x.id === queryId)
  109. const resolvedRange = useMemo(() => {
  110. if (datePickerValue.isHelper) {
  111. return resolveLogDateRange(datePickerValue)
  112. }
  113. if (timestampStart && timestampEnd) {
  114. return { from: timestampStart, to: timestampEnd }
  115. }
  116. return resolveLogDateRange(datePickerValue)
  117. }, [timestampStart, timestampEnd, datePickerValue])
  118. const {
  119. params,
  120. logData,
  121. error,
  122. isLoading: logsLoading,
  123. setParams,
  124. } = useLogsQuery(
  125. projectRef,
  126. {
  127. iso_timestamp_start: resolvedRange.from,
  128. iso_timestamp_end: resolvedRange.to,
  129. },
  130. true,
  131. { useOtel: useOtelEndpoint }
  132. )
  133. const results = logData
  134. const isLoading = logsLoading
  135. const { mutateAsync: upsertContent, isPending: isUpsertingContent } = useContentUpsertMutation({
  136. onError: (e) => {
  137. const error = e as { message: string }
  138. console.error(error)
  139. setSaveModalOpen(false)
  140. if (queryId) {
  141. toast.error(`Failed to update query: ${error.message}`)
  142. } else {
  143. toast.error(`Failed to save query: ${error.message}`)
  144. }
  145. },
  146. onSuccess: (_data, vars) => {
  147. setSaveModalOpen(false)
  148. if (queryId) {
  149. toast.success(`Updated "${vars.payload.name}" log query`)
  150. } else {
  151. toast.success(`Saved "${vars.payload.name}" log query`)
  152. }
  153. },
  154. })
  155. const addRecentLogSqlSnippet = (snippet: Partial<LogSqlSnippets.Content>) => {
  156. const defaults: LogSqlSnippets.Content = {
  157. schema_version: '1',
  158. sql: '',
  159. content_id: '',
  160. }
  161. setRecentLogs([...recentLogs, { ...defaults, ...snippet }])
  162. }
  163. const { showUpgradePrompt, setShowUpgradePrompt } = useUpgradePrompt(
  164. params.iso_timestamp_start as string
  165. )
  166. const onSelectTemplate = (template: LogTemplate) => {
  167. if (editorRef.current && monaco) {
  168. const editorModel = editorRef.current?.getModel()
  169. editorRef.current.pushUndoStop()
  170. editorRef.current.executeEdits(`insert-identifier`, [
  171. {
  172. text: template.searchString,
  173. range: editorModel?.getFullModelRange() ?? new monaco.Range(1, 1, 1, 1),
  174. },
  175. ])
  176. editorRef.current.pushUndoStop()
  177. editorRef.current.focus()
  178. }
  179. addRecentLogSqlSnippet({ sql: template.searchString })
  180. }
  181. const handleRun = (value?: string | React.MouseEvent) => {
  182. track('log_explorer_query_run_button_clicked', { is_saved_query: !!queryId })
  183. const query = typeof value === 'string' ? value || editorValue : editorValue
  184. const resolvedParams = buildLogQueryParams(datePickerValue, query)
  185. setParams((prev) => ({
  186. ...prev,
  187. sql: resolvedParams.sql,
  188. iso_timestamp_start: resolvedParams.from,
  189. iso_timestamp_end: resolvedParams.to,
  190. }))
  191. if (!datePickerValue.isHelper) {
  192. setTimeRange(resolvedParams.from, resolvedParams.to)
  193. } else {
  194. setTimeRange('', '')
  195. }
  196. setSearch(query)
  197. addRecentLogSqlSnippet({ sql: query })
  198. }
  199. const handleInsertSource = (source: string) => {
  200. if (editorRef.current && monaco) {
  201. const editorModel = editorRef.current?.getModel()
  202. const currentValue = editorRef.current.getValue()
  203. const index = currentValue.indexOf('from')
  204. const updatedValue =
  205. index < 0
  206. ? `${currentValue}${source}`
  207. : `${currentValue.substring(0, index + 4)} ${source} ${currentValue.substring(index + 5)}`
  208. editorRef.current.pushUndoStop()
  209. editorRef.current.executeEdits(`insert-identifier`, [
  210. {
  211. text: updatedValue,
  212. range: editorModel?.getFullModelRange() ?? new monaco.Range(1, 1, 1, 1),
  213. },
  214. ])
  215. editorRef.current.pushUndoStop()
  216. editorRef.current.focus()
  217. }
  218. }
  219. type SaveQueryFormValues = { name: string; description?: string }
  220. const handleCreateQuery = async (values: SaveQueryFormValues) => {
  221. if (!projectRef) return console.error('Project ref is required')
  222. if (!profile) return console.error('Profile is required')
  223. const id = uuidv4()
  224. const payload: UpsertContentPayload = {
  225. id,
  226. name: values.name,
  227. description: values.description || '',
  228. type: 'log_sql' as const,
  229. content: {
  230. content_id: editorId,
  231. sql: editorValue,
  232. schema_version: '1',
  233. favorite: false,
  234. } as LogSqlSnippets.Content,
  235. owner_id: profile.id,
  236. visibility: 'user' as const,
  237. }
  238. await upsertContent(
  239. { projectRef, payload },
  240. {
  241. onSuccess: () => router.push(`/project/${projectRef}/logs/explorer?queryId=${id}`),
  242. }
  243. )
  244. }
  245. async function handleOnSave() {
  246. if (!projectRef) return console.error('Project ref is required')
  247. // if we have a queryId, we are editing a saved query
  248. if (queryId && query) {
  249. await upsertContent({
  250. projectRef: projectRef!,
  251. payload: {
  252. ...query,
  253. content: { ...(query.content as LogSqlSnippets.Content), sql: editorValue },
  254. },
  255. })
  256. return
  257. }
  258. setSaveModalOpen(!saveModalOpen)
  259. }
  260. const handleDateChange = (value: DatePickerValue) => {
  261. setDatePickerValue(value)
  262. const resolvedRange = resolveLogDateRange(value)
  263. const shouldShowUpgradePrompt = maybeShowUpgradePromptIfNotEntitled(
  264. resolvedRange.from,
  265. entitledToAuditLogDays
  266. )
  267. if (shouldShowUpgradePrompt) {
  268. setShowUpgradePrompt(true)
  269. return
  270. }
  271. if (value.isHelper) {
  272. setTimeRange('', '')
  273. } else {
  274. setTimeRange(resolvedRange.from || '', resolvedRange.to || '')
  275. }
  276. setParams((prev) => ({
  277. ...prev,
  278. iso_timestamp_start: resolvedRange.from,
  279. iso_timestamp_end: resolvedRange.to,
  280. }))
  281. }
  282. useEffect(() => {
  283. if (search) {
  284. setEditorValue(search)
  285. } else if (q) {
  286. setEditorValue(q)
  287. setSearch(q)
  288. }
  289. }, [q, search, setSearch])
  290. useEffect(() => {
  291. // prevents overwriting when the user selects a helper.
  292. // without this, if the user selects "last 3 days" it would overwrite it with "last hour"
  293. // its the simplest solution I could come up with - jordi
  294. if (!initialDatePickerValue.isHelper) {
  295. setDatePickerValue(initialDatePickerValue)
  296. }
  297. }, [initialDatePickerValue])
  298. useEffect(() => {
  299. let newWarnings = []
  300. const start = timestampStart ? dayjs(timestampStart) : dayjs()
  301. const end = timestampEnd ? dayjs(timestampEnd) : dayjs()
  302. const daysDiff = Math.abs(start.diff(end, 'days'))
  303. if (daysDiff >= LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD) {
  304. newWarnings.push({
  305. text: 'Querying large date ranges can be slow. Consider selecting a smaller date range.',
  306. })
  307. }
  308. if (editorValue && !editorValue.toLowerCase().includes('limit')) {
  309. newWarnings.push({ text: 'When querying large date ranges, include a LIMIT clause.' })
  310. }
  311. setWarnings(newWarnings)
  312. }, [editorValue, timestampStart, timestampEnd])
  313. // Show the prompt on page load based on query params
  314. useEffect(() => {
  315. if (timestampStart) {
  316. const shouldShowUpgradePrompt = maybeShowUpgradePromptIfNotEntitled(
  317. timestampStart,
  318. entitledToAuditLogDays
  319. )
  320. if (shouldShowUpgradePrompt) {
  321. setShowUpgradePrompt(true)
  322. }
  323. }
  324. }, [timestampStart, entitledToAuditLogDays, setShowUpgradePrompt])
  325. return (
  326. <div className="w-full h-full mx-auto">
  327. <ResizablePanelGroup
  328. className="w-full h-full max-h-screen"
  329. orientation="vertical"
  330. autoSaveId={LOCAL_STORAGE_KEYS.LOG_EXPLORER_SPLIT_SIZE}
  331. >
  332. <ResizablePanel collapsible minSize="5">
  333. <LogsQueryPanel
  334. value={datePickerValue}
  335. onDateChange={handleDateChange}
  336. onSelectSource={handleInsertSource}
  337. templates={allTemplates.filter((template) => template.mode === 'custom')}
  338. onSelectTemplate={onSelectTemplate}
  339. warnings={warnings}
  340. useOtel={useOtelEndpoint}
  341. onUseOtelChange={setUseOtelEndpoint}
  342. />
  343. <ShimmerLine active={isLoading} />
  344. <CodeEditor
  345. // Ensure we reset the editor to the query content whenever the selected query changes
  346. key={queryId}
  347. id={editorId}
  348. editorRef={editorRef}
  349. language="pgsql"
  350. defaultValue={editorValue}
  351. onInputChange={(v) => setEditorValue(v || '')}
  352. actions={{ runQuery: { enabled: true, callback: handleRun } }}
  353. />
  354. </ResizablePanel>
  355. <ResizableHandle withHandle />
  356. <ResizablePanel collapsible minSize="5" className="overflow-auto">
  357. <LoadingOpacity active={isLoading}>
  358. <LogTable
  359. isSaving={isUpsertingContent}
  360. showHistogramToggle={false}
  361. onRun={handleRun}
  362. onSave={handleOnSave}
  363. hasEditorValue={Boolean(editorValue)}
  364. data={results}
  365. error={error}
  366. projectRef={projectRef}
  367. onSelectedLogChange={setSelectedLog}
  368. selectedLog={selectedLog || undefined}
  369. sqlQuery={editorValue}
  370. />
  371. <div className="flex flex-row justify-end mt-2">
  372. <UpgradePrompt show={showUpgradePrompt} setShowUpgradePrompt={setShowUpgradePrompt} />
  373. </div>
  374. </LoadingOpacity>
  375. </ResizablePanel>
  376. </ResizablePanelGroup>
  377. <UpdateSavedQueryModal
  378. header="Save log query"
  379. visible={saveModalOpen}
  380. initialValues={{ name: '', description: '' }}
  381. onCancel={() => {
  382. setSaveModalOpen(false)
  383. }}
  384. onSubmit={handleCreateQuery}
  385. />
  386. </div>
  387. )
  388. }
  389. LogsExplorerPage.getLayout = (page) => (
  390. <DefaultLayout>
  391. <LogsLayout title="Explorer">{page}</LogsLayout>
  392. </DefaultLayout>
  393. )
  394. export default LogsExplorerPage